feat: add composable low-level table components (TableRoot, TableHead, TableRow, TableHeaderCell, TableBodyCell) - #4977
gethinwebster wants to merge 24 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4977 +/- ##
========================================
Coverage 97.68% 97.69%
========================================
Files 965 986 +21
Lines 31478 31646 +168
Branches 11639 11684 +45
========================================
+ Hits 30748 30915 +167
- Misses 684 724 +40
+ Partials 46 7 -39 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…ableBodyCell - Rename TableCell -> TableBodyCell; it now renders row headers too via isRowHeader (th scope="row"), so TableHeaderCell is column-headers only (drops its scope prop). - Remove TableHeaderRow; the header row is TableRow variant="header", rendered through one unified TableRow path that shares the base .row / .row-grid box and CSS (header adds only a background). - Drop the head/body section context: variant (default|selected|shaded| header) is the single row discriminant, stamped dynamically as a data-awsui-variant-* hook (default carries none; new variants need no change here). - ariaRowcount no longer travels through table context; header rows take an explicit ariaRowindex like body rows, and gain aria-*/positionStyle. - Remove the TableHeaderRow test-util wrapper (not a component). - Migrate dev pages and unit tests; regenerate documenter + test-utils snapshots.
Header rows no longer need a distinct row variant. A header row's visual identity (the shaded background) lives on TableHeaderCell, and its structural role is conveyed by placement inside TableHead, so TableRow variant narrows to default | selected | shaded. This lets two more things go: - The row-level header background only backed the grid underfill strip (header cells paint themselves); with it removed, an underfill header shows cells shaded to their tracks, consistent with body rows. - grid-auto-rows only imposed a minimum row height on all-short rows; dropping it makes grid rows content-sized, matching auto mode and the existing Table, neither of which has a row-height floor.
…n tests Replace the hand-rolled ResizeObserver in TableRoot with two useResizeObserver calls (the scroller and the table it wraps), matching how the rest of the codebase observes elements and getting a synchronous initial measure for free. The re-measure effect narrows to gridTemplateColumns — the one overflow transition an observer misses (grid tracks overflow without either observed box resizing); auto-layout content growth resizes the table box and is caught by the child observer. The unit overflow-region test is rewritten to drive the transition through a column-template change (no ResizeObserver mock, which the shared hook's entry conversion would otherwise require). A new integ suite covers the two paths jsdom can't: keyboard-scrollability of the focusable region, a viewport resize starting/stopping the overflow (scroller observer), and auto-layout content growth starting it (table-box observer).
… data-attr RowVariantContext's only runtime effect was the atomic body cell's shaded-background class. Selection already paints from the row's data-awsui-variant-selected attribute via a `> .cell` rule, and the shaded *border* already did too — only the shaded *background* went through the context. Move it to a matching `[data-awsui-variant-shaded] > .cell` rule and the context has no consumers, so delete it along with the per-row provider and both `value="default"` resets (TableRoot and the existing Table). The existing Table keeps its TableContextProvider reset (defaultTableContext): its cells are the shared substrate and read the ambient column layout, so without it a Table nested in a grid-layout atomic cell would render grid roles/classes. That reset is load-bearing; the RowVariant one was not.
The variant tests asserted that atomic cells do NOT carry the existing Table's per-cell selection/shading classes — testing the absence of a class-based mechanism the atomic never used now that selection and shading both paint from the row's data-awsui-variant-* attribute. Drop those per-cell class-absence loops and the unused helper; each variant test now asserts the real contract (the correct data-awsui-variant-* hook on the <tr>, mutual exclusivity, and no aria-selected). Also delete two tests that guarded removed/never-built behavior: the stray-cell test that existed only to check the deleted RowVariantContext default, and the grid roving-tabindex negative (a deferred feature the atomic never implemented).
| * Renders the cell as a row header (`<th scope="row">`) instead of a data cell (`<td>`). A row header | ||
| * keeps the data-cell styling; use it for the cell that names its row. Defaults to `false`. | ||
| */ | ||
| isRowHeader?: boolean; |
There was a problem hiding this comment.
What about making it more explicit such as:
tag: "td" | "th"; // td by default
There was a problem hiding this comment.
isRowHeader also sets the role/scope attributes, so I think it's better to have an abstracted name than more explicit
| ) => { | ||
| const { columnLayout } = useTableContext(); | ||
| const variant = useRowVariant(); | ||
| const isVisualRefresh = useVisualRefresh(); |
There was a problem hiding this comment.
Should this be any special treatment for one-theme as well?
There was a problem hiding this comment.
the useVisualRefresh hook returns true for onetheme too
|
|
||
| import styles from './styles.css.js'; | ||
|
|
||
| export interface InternalTableBodyProps extends TableBodyProps, InternalBaseComponentProps {} |
There was a problem hiding this comment.
Do you need to export this interface?
| ref | ||
| ) => { | ||
| const { columnLayout } = useTableContext(); | ||
| const isVisualRefresh = useVisualRefresh(); |
There was a problem hiding this comment.
The same question about one-theme here
There was a problem hiding this comment.
(see above, handled by useVisualRefresh)
| return `${size}px`; | ||
| } | ||
| const min = `${clamp(column.minWidth) ?? 0}px`; | ||
| const flex = typeof column.size === 'object' ? clamp(column.size.flex) : undefined; |
There was a problem hiding this comment.
[nit]:
| const flex = typeof column.size === 'object' ? clamp(column.size.flex) : undefined; | |
| const flex = !!column.size?.flex ? clamp(column.size.flex) : undefined; |
…sed interface export
… props; selection wins over shading
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The row API conflicts with the documented contract, zero flex weights compile incorrectly, and public head documentation names a nonexistent component.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Open (10)
TableRow API lacks documented variant discriminant · New Zero flex weight incorrectly defaults to 1fr · NewdisablePaddingsdoes not actually remove all padding from edge cells. The existing selectors such… The rootdisable-paddingsrule can be overridden by the legacy header substrate's… This attribute-only selector is emitted globally by CSS Modules, so any consumer element using the… This shared header-cell substrate also reads the nearest atomicTableRootcontext. A high-level… The shared substrate now consumes ambient atomic-table contexts even when it is rendering cells for…aria-rowcountcounts every row in the accessibility tree, including the header. This prop is… Docs reference nonexistent TableHeaderRow component · New The new overflow accessibility branch is untested: the current role tests only cover a…
| selected?: boolean; | ||
| /** | ||
| * Applies a shaded background, for alternating row colors. | ||
| */ | ||
| shaded?: boolean; |
…ow doc references
| <Input | ||
| type="number" | ||
| value={config.maxWidth} | ||
| disabled={config.mode !== 'capped'} |
There was a problem hiding this comment.
nit: Why do we hide fields in one case (flex weight) and disabled in the other (max width)?
| export default function TableScrollRegionPage() { | ||
| const [grown, setGrown] = useState(false); | ||
| return ( | ||
| <Box padding="l"> |
There was a problem hiding this comment.
nit: can use SimplePage helper here
| width={400} | ||
| /> | ||
| <Grid label="Merge (two consecutive selected rows)" columnLayout={flex3} selected={[0, 1]} /> | ||
| <Grid label="Shaded (striped rows)" columnLayout={flex3} shaded={[0, 2]} /> |
There was a problem hiding this comment.
nit: This one shows a shaded table that does not have selection - this is mildly off as the page is called "selection-edge-cases"
| // only `selected` (the visual row surface; the checkbox/radio conveys selection to | ||
| // assistive technologies). The control column uses `disablePaddings` cells with a centred control to | ||
| // match the existing Table's selection column. | ||
| // Control column is fixed; Name and Status share the remaining width via flex weights (rather than |
There was a problem hiding this comment.
nit: this comments block can be shorter - the columns setup description feels redundant for the purpose of this page
| return ( | ||
| <SimplePage title="Table atomics — grid selection edge cases" screenshotArea={{}}> | ||
| <Grid label="Fill (flex, selected row)" columnLayout={flex3} selected={[1]} /> | ||
| <Grid label="Underfill (capped columns, selected row)" columnLayout={capped3} selected={[1]} /> |
There was a problem hiding this comment.
what does "Underfill" mean here?
There was a problem hiding this comment.
It's a new edge-case within the grid layout, where we don't auto-expand the last column to fit the full available space. I'll make the label a bit clearer.
| const items = makeItems(ITEM_COUNT); | ||
| const { urlParams, setUrlParams } = useAppContext<'selectionMode'>(); | ||
| const mode: SelectionMode = urlParams.selectionMode === 'single' ? 'single' : 'multi'; | ||
| const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set([items[1].id])); |
There was a problem hiding this comment.
nit: if selection state is moved to the uri and we add a couple extra settings (shaded cells, overflow) - then grid selection edge cases page can be removed, as all edge cases will be replecatable here with ease
There was a problem hiding this comment.
I think it's worth keeping: it makes manual checks a lot easier (rather than having to remember all combinations to check)
| import { DataHeader, makeItems } from './common'; | ||
|
|
||
| // A minimal read-only table in auto layout (`columnLayout` omitted, so it defaults to `{ type: 'auto' }`). | ||
| // Striping is composed by the consumer via the row `shaded` prop: with the toggle on, alternating rows are |
There was a problem hiding this comment.
nit: The page settings description feels redundant - the setup is clear from the rendered page and the code.
| style={{ ...resizableStyle, ...stickyStyles.style }} | ||
| className={clsx( | ||
| styles['body-cell'], | ||
| isSelected && styles['body-cell-selected'], |
There was a problem hiding this comment.
Why do we pass these isSelected, isNextSelected, etc.? Should we use the [data-awsui-selected] on the row instead? Otherwise, there are two parallel mechanisms for selection styles.
| isNextSelected && styles['body-cell-next-selected'], | ||
| isPrevSelected && styles['body-cell-prev-selected'], | ||
| !isEvenRow && stripedRows && styles['body-cell-shaded'], | ||
| stripedRows && styles['has-striped-rows'], |
There was a problem hiding this comment.
Same here - can we use [data-awsui-shaded]?
| export interface InternalTableRootProps extends TableRootProps, InternalBaseComponentProps {} | ||
|
|
||
| export default function InternalTableRoot({ | ||
| columnLayout = { type: 'auto' }, |
There was a problem hiding this comment.
nit: I would use InternalTableRootProps = SomeRequired<TableRootProps, 'columnLayout'> here - so that we set the default in one place.
| ...rest | ||
| }: InternalTableRootProps) { | ||
| const isGrid = columnLayout.type === 'grid'; | ||
| const table = useTableRoot(columnLayout); |
There was a problem hiding this comment.
nit: useTableRoot uses columnLayout in a dependency list for memoization - however, it will be invalidated at every render because we create a fresh { type: 'auto' } for defaults.
| .map(column => { | ||
| const size = typeof column.size === 'number' ? clamp(column.size) : undefined; | ||
| if (size !== undefined) { | ||
| return `${size}px`; |
There was a problem hiding this comment.
What if min width is also set - should we consider it here, too?



What this adds
A set of low-level, composable table components —
TableRoot,TableHead,TableBody,TableRow,TableHeaderCell, andTableBodyCell. They let a consumer assemble a table from primitives, as a lower-level alternative to the existing high-levelTablecomponent.Composition model
TableRowcomponent. Data rows go inTableBody; the column-header row is a plainTableRowplaced insideTableHead— there is no separate header-row component or row variant. A row's visual state is set with independent, composableselectedandshadedboolean props (both visual-only).selectedconveys nothing to assistive technology — the selection control in a leading cell does — and takes precedence overshaded.TableHeaderCellis the column header (<th scope="col">);TableBodyCellis the data cell (<td>). A row header is a<th scope="row">that visually matches a data cell, so it is aTableBodyCell isRowHeaderrather than a header cell.TableRootrenders no rows itself.How they are built
Rather than reimplementing table cell and row markup and styling from scratch — which would risk drifting visually from the existing
Table— these components are built on the same internal building blocks the existingTablealready uses for its own cells and rows. The existingTableis refactored to compose those same building blocks.The result is a single shared implementation of the cell/row box model and its visual states (selection, shading, dividers, spacing), instead of two implementations that could diverge over time. A row's visual state is carried on the
<tr>asdata-awsui-selected/data-awsui-shadedhooks that the shared cell styles read.Effect on the existing Table
The existing
Tablerenders exactly as before. The refactor only changes which internal pieces it composes, not the markup or styles it produces. The new components' additional styling is keyed on markers the existingTablenever sets, so the shared styles are reused (not duplicated) and stay inert for the existingTable.Verification
Tableis confirmed visually unchanged across selection, striping, sticky columns, inline editing, loading/empty, footer, and grouped-header states.Tableoutput — including selection-control alignment and the absence of any content shift when a row's selection is toggled.Notes for reviewers